Fragment Masking で Fragment Colocation を強制する
https://gyazo.com/90c31008b9e7c044f52c944bbfb9e23c
鹿港 / 摸乳巷
Fragment Colocationとは
ReactのアプリケーションでGraphQLを使うときに使われるテクニックの一つです。Fragment Colocationによって、データのフェッチ処理と、データをコンポーネントに描画する処理を分離できることなどがメリットです。詳しくはこちらも参考にしてください。
https://ogp-image.deno.dev/svg?url=https://zenn.dev/moneyforward/articles/20221211-fragment-colocation#.png https://zenn.dev/moneyforward/articles/20221211-fragment-colocation
実装例を見てみます。なお、本記事のコードは関係の深い部分のみを抜粋したものになりますので、importや足りない部分を調整してください。
code:Pokemon.tsx
gql(`
fragment Pokemon on Pokemon {
number
name
image
}
`);
export const Pokemon = ({ pokemon }: { pokemon: PokemonFragment }) => {
return (
<div>
<a href={/pokemon/${pokemon.number}}>
<h3>{pokemon.name}</h3>
<img width={100} height={100} src={pokemon.image} />
</a>
</div>
);
};
実装時のポイントは、Fragmentの定義の側にそのFragmentをPropsとして受け取るコンポーネントを配置(colocate)することです。
PokemonFragmentのような型を自動生成するのにGraphQL Code Generatorを使っています。PokemonコンポーネントはPokemonFragmentをPropsで受け取りJSX.Elementを返しますが、データの取得方法に関する詳細が書かれていません。このようにデータの取得と描画を分離することで、このコンポーネントの保守性が上がります。ただし、この書き方ではFragment Colocationを強制することができません。具体的には以下のようにデータの取得する場所でもFragmentのフィールドにアクセスすることができてしまいます。
code:index.tsx
const PokemonsQuery = gql(`
query pokemons($first: Int!){
pokemons(first: $first){
id
...Pokemon
}
}
`);
const Index = () => {
const data, fetching, error } = useQuery({
query: PokemonsQuery,
variables: {
first: 151,
},
});
return (
<ul>
{data?.pokemons?.map(
(pokemon) =>
pokemon && (
<li>
{/* Pokemonコンポーネントの外でPokemonFragmentのフィールドにアクセスできてしまう */}
<h2>{pokemon.number}</h2>
<Pokemon pokemon={pokemon} />
</li>
)
)}
</ul>
);
};
Colocationされたコンポーネント以外でFragmentの値にアクセスしてしまうと、もしFragmentのフィールドを変更した場合に呼び出し側のコンポーネントが壊れる可能性があります。また、見かけ上Fragmentのフィールドを使用していることに気づきにくいという問題があります。これはFragment Colocationの実装が不十分で保守性を下げることに繋がります。この問題を解決するアイデアの一つがFragment Maskingです。
Fragment Maskingとは
GraphQL Code GeneratorにRelayスタイルのFragment Colocationが実装されているので、今すぐ使うことができます。ということで、今回はgql-tag-operations-presetプラグインを用いて実際に使ってみます。 gql-tag-operations-presetでFragment Maskingを使ってみる
gql-tag-operations-presetやGraphQLクライアントの導入自体は済んでいる前提で進めます。
まずは、codegen.tsの設定で有効にします。といっても簡単で、presetConfig: { fragmentMasking: true } を入れてあげるだけです。
code:codegen.ts
const config: CodegenConfig = {
...
generates: {
'graphql/__generated__/': {
preset: 'gql-tag-operations-preset',
presetConfig: {
fragmentMasking: true,
},
plugins: [],
config: {
...
},
},
},
};
Fragment Maskingを有効にしてコードを再生成すると、カプセル化が効いてフラグメントのフィールドにアクセスできなくなります。さらに、FragmentのオブジェクトをPropsに渡す部分でもエラーが出ています。
https://gyazo.com/0dd727d43d3fc1af1bc3ac8358c89889
生成したコードを読むとわかりますが、$fragmentRefsというキーをもつ型に変換されていて、直接PokemonFragmentへ参照できなくなりました。
code:1. graphql/__generated__/graphql.ts
// fragmentMasking: false(またはデフォルト)の場合
export type PokemonsQuery = { __typename?: 'Query', pokemons: Array<{ __typename?: 'Pokemon', id: string, number: string | null, name: string | null, image: string | null } | null> | null };
code:2. graphql/__generated__/graphql.ts
// fragmentMasking: true の場合
export type PokemonsQuery = { __typename?: 'Query', pokemons: Array<(
{ __typename?: 'Pokemon', id: string }
& { ' $fragmentRefs'?: { 'PokemonFragment': PokemonFragment } }
) | null> | null };
これをカプセル化されたコンポーネントの中だけで参照できるように変えていきます。
code:Pokemon.tsx
import { FragmentType, gql, useFragment } from '../graphql/__generated__';
export const PokemonFragment = gql(`
fragment Pokemon on Pokemon {
number
name
image
}
`);
export const Pokemon = ({
pokemon,
}: {
pokemon: FragmentType<typeof PokemonFragment>;
}) => {
const pokemonFragment = useFragment(PokemonFragment, pokemon);
return (
<div>
<a href={/pokemon/${pokemonFragment.number}}>
<h3>{pokemonFragment.name}</h3>
<img width={100} height={100} src={pokemonFragment.image!} />
</a>
</div>
);
};
ポイントは、Propsの型をFragmentから、FragmentType<typeof Fragment>に変更していることです。この型のオブジェクトはそのままで使うことができないので、useFragment(Fragment, fragment)を使って使えるようにします。このuseFragmentはいわゆるReactのHooksではなく、ただの関数です。どのような関数かは生成されたコードを読むとわかります。非常に単純で、as any で戻り値の型を明示してキャストしているだけですね。
code:graphql/__generated__/fragment-masking.ts
// return non-nullable if fragmentType is non-nullable
export function useFragment<TType>(
_documentNode: DocumentNode<TType, any>,
fragmentType: FragmentType<DocumentNode<TType, any>>
): TType;
// return nullable if fragmentType is nullable
export function useFragment<TType>(
_documentNode: DocumentNode<TType, any>,
fragmentType: FragmentType<DocumentNode<TType, any>> | null | undefined
): TType | null | undefined;
// return array of non-nullable if fragmentType is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentNode<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentNode<TType, any>>>
): ReadonlyArray<TType>;
// return array of nullable if fragmentType is array of nullable
export function useFragment<TType>(
_documentNode: DocumentNode<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentNode<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentNode<TType, any>,
fragmentType: FragmentType<DocumentNode<TType, any>> | ReadonlyArray<FragmentType<DocumentNode<TType, any>>> | null | undefined
): TType | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
つまり、オブジェクトにデータはあるものの、TypeScriptの型の上でFragmentのデータを隠蔽(Masking)することでアクセスできないようにしています。そしてColocationしたコンポーネント内部でキャストして使えるようにします。
テストやStorybookでモックのFragmentオブジェクトを入れたい場合
makeFragmentDataメソッドを作りPropsでFragmentオブジェクトを渡してあげましょう。
code:makeFragmentData.ts
import type {
ResultOf,
TypedDocumentNode as DocumentNode,
} from '@graphql-typed-document-node/core';
import type { FragmentType } from '~/generated';
export function makeFragmentData<
F extends DocumentNode,
FT extends ResultOf<F>,
(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}
code:component.ts
import { Pokemon, PokemonFragment } from '../components';
<Pokemon
pokemon={makeFragmentData(
{
number: '1',
name: 'ピカチュウ',
},
PokemonFragment
)}
/>
こちらもキャストしているだけですね。
※ ちなみに、本題とはそれますが client preset を使う場合はmakeFragmentData(data, fragment)が生成されるコードに含まれているため、メソッドを作る必要はありません。
Fragmentの中でも外でも使いたいフィールドはどうするか
Fragment Colocationされたコンポーネントの中でも、クエリの呼び出し側でも使いたいフィールドがある場合は、クエリ(ミューテーション)に直接フィールドを定義した上で、参照するFragmentにもフィールドを定義してあげましょう。
まとめ
Fragment MaskingはFragment Colocationを強制するためにとても便利な機能です。自動生成されたコードを読めばわかるように、実際に行っていることは型によるFragmentデータのカプセル化だけなのでGraphQLのクエリやミューテーションに影響を及ぼすことはありません。
また、現在GraphQL Code Generatorではv3のロードマップが敷かれていて、client preset という便利なプリセットが作成されています。このプリセットは既にurqlのドキュメントで使われていたりして、今後のデファクトプリセットになる可能性があります。client presetでは今回紹介したFragment Maskingがデフォルトで有効になっていることからも、積極的に使っていくことは悪くない選択肢だと思います。 https://ogp-image.deno.dev/svg?url=https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#.png https://the-guild.dev/graphql/codegen/plugins/presets/preset-client
参考
https://i.gyazo.com/51454271bc5520c96d424a29a19498c4.png https://twitter.com/share?url=https%3A%2F%2Fscrapbox.io%2Fwada-blog%2FFragment_Masking_%25E3%2581%25A7_Fragment_Colocation_%25E3%2582%2592%25E5%25BC%25B7%25E5%2588%25B6%25E3%2581%2599%25E3%2582%258B&text=Fragment+Masking+%E3%81%A7+Fragment+Colocation+%E3%82%92%E5%BC%B7%E5%88%B6%E3%81%99%E3%82%8B < Twitterでシェア
宣伝
/wadata/モニクル.icon モニクルでは、はたらく世代・子育て世代が抱えるお金や資産運用に関する不安や悩みを解決するお手伝いをさせていただき、現在から老後に至るまでの資金計画や最適なポートフォリオの提案を行っております。
会社の紹介スライドはこちらから!
https://ogp-image.deno.dev/svg?url=https://speakerdeck.com/monicle/about#.png https://speakerdeck.com/monicle/about